Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit a31fa287e29db2a5713a771de2d1503eb2f5fcfe


Parents : 80f91b3
Author : Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-11T19:00:38-07:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T02:00:38Z

feat(tak): show local server status (#6599)

Changes

21 files changed, 411 insertions(+), 32 deletions(-)


Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 8c8e1fdad4..a909fe5141 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1658,6 +1658,14 @@ tak_server_loading
tak_server_mesh_to_cot
tak_server_mesh_to_cot_desc
tak_server_section
+tak_server_status
+tak_server_status_connected
+tak_server_status_failed
+tak_server_status_not_running
+tak_server_status_off
+tak_server_status_starting
+tak_server_status_unavailable
+tak_server_status_waiting
tak_server_test_card_title
tak_server_test_idle
tak_server_test_result_bytes

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 4b7af67a04..06cb046cbc 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1697,12 +1697,23 @@
<string name="tak_role_unspecified">Unspecified</string>
<string name="tak_server">TAK Server</string>
<string name="tak_server_enabled">Enable Local TAK Server</string>
- <string name="tak_server_enabled_desc">Starts a local TLS server on port 8089 for ATAK/iTAK connections</string>
- <string name="tak_server_export_data_package_desc">Generate .zip for ATAK/iTAK to connect to this server</string>
+ <string name="tak_server_enabled_desc">Starts a local TLS server on port 8089 for ATAK connections</string>
+ <string name="tak_server_export_data_package_desc">Generate .zip for ATAK to connect to this server</string>
<string name="tak_server_loading">…</string>
<string name="tak_server_mesh_to_cot">Mesh to CoT Converter</string>
- <string name="tak_server_mesh_to_cot_desc">Show Meshtastic nodes on the ATAK/iTAK map as contacts</string>
+ <string name="tak_server_mesh_to_cot_desc">Show Meshtastic nodes on the ATAK map as contacts</string>
<string name="tak_server_section">Server</string>
+ <string name="tak_server_status">Status</string>
+ <plurals name="tak_server_status_connected">
+ <item quantity="one">%1$d local client connection</item>
+ <item quantity="other">%1$d local client connections</item>
+ </plurals>
+ <string name="tak_server_status_failed">Unable to start TAK Server. Turn it off and on to retry.</string>
+ <string name="tak_server_status_not_running">TAK Server is not running</string>
+ <string name="tak_server_status_off">Off</string>
+ <string name="tak_server_status_starting">Starting TAK Server</string>
+ <string name="tak_server_status_unavailable">Local TAK Server is available in Meshtastic for Android</string>
+ <string name="tak_server_status_waiting">Listening on 127.0.0.1:8089 β€” waiting for ATAK</string>
<string name="tak_server_test_card_title">TAK Mesh Test (Debug)</string>
<string name="tak_server_test_idle">Send all %1$d test fixtures to mesh</string>
<string name="tak_server_test_result_bytes">%1$dB βœ“</string>

diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
index 3afe5df6eb..6264eedac2 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt
@@ -115,7 +115,7 @@ class MeshServiceOrchestrator(
if (isEnabled && !takServerManager.isRunning.value) {
Logger.i { "TAK Server enabled by preference, starting integration" }
takMeshIntegration.start(newScope)
- } else if (!isEnabled && takServerManager.isRunning.value) {
+ } else if (!isEnabled) {
Logger.i { "TAK Server disabled by preference, stopping integration" }
takMeshIntegration.stop()
}
@@ -189,10 +189,7 @@ class MeshServiceOrchestrator(
*/
fun stop() {
Logger.i { "Stopping mesh service orchestrator" }
- // Guard stop() so we don't emit a spurious "stopped" log when TAK was never started
- if (takServerManager.isRunning.value) {
- takMeshIntegration.stop()
- }
+ takMeshIntegration.stop()
// Best-effort polite goodbye on service teardown (onDestroy / process shutdown). We launch
// on a fresh detached scope β€” not the orchestrator's per-start scope β€” so the subsequent
// scope.cancel() below doesn't interrupt the short drain delay inside disconnect(). The

diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
index 66a1a79653..9e3a444a0b 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
@@ -203,6 +203,63 @@ class MeshServiceOrchestratorTest {
orchestrator.stop()
}
+ @Test
+ fun testTakServerCanRetryAfterFailedStart() {
+ val takEnabledFlow = MutableStateFlow(false)
+ val takRunningFlow = MutableStateFlow(false)
+ val lifecycleEvents = mutableListOf<String>()
+ every { takServerManager.start(any()) } calls
+ {
+ lifecycleEvents += "start"
+ Unit
+ }
+ every { takServerManager.stop() } calls
+ {
+ lifecycleEvents += "stop"
+ Unit
+ }
+ val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow)
+
+ orchestrator.start()
+ // The mock never changes takRunningFlow, modeling a start attempt that failed before listening.
+ takEnabledFlow.value = true
+ takEnabledFlow.value = false
+ takEnabledFlow.value = true
+
+ assertEquals(listOf("start", "stop", "start"), lifecycleEvents)
+
+ orchestrator.stop()
+ assertEquals(listOf("start", "stop", "start", "stop"), lifecycleEvents)
+ }
+
+ @Test
+ fun testStopStopsTakServerWhileStarting() {
+ val takEnabledFlow = MutableStateFlow(true)
+ val takRunningFlow = MutableStateFlow(false)
+ val lifecycleEvents = mutableListOf<String>()
+ every { takServerManager.start(any()) } calls
+ {
+ lifecycleEvents += "start"
+ Unit
+ }
+ every { takServerManager.stop() } calls
+ {
+ lifecycleEvents += "stop"
+ Unit
+ }
+ val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow)
+
+ orchestrator.start()
+ orchestrator.stop()
+
+ takEnabledFlow.value = false
+ orchestrator.start()
+ takEnabledFlow.value = true
+ orchestrator.stop()
+
+ assertEquals(listOf("start", "stop", "start", "stop"), lifecycleEvents)
+ }
+
@Test
fun testStartCallsSwitchActiveDatabase() {
// New ordering: start() waits for currentDeviceAddressFlow to surface a valid address,

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt
index 0a55f4fda0..296382326f 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServer.kt
@@ -32,6 +32,10 @@ import org.meshtastic.core.di.CoroutineDispatchers
*/
interface TAKServer {
+ /** Whether this platform provides a local TAK listener. */
+ val isSupported: Boolean
+ get() = true
+
/** Observable count of currently-connected TAK clients (ATAK/iTAK). */
val connectionCount: StateFlow<Int>

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt
index c5391ce2c3..0bd99ef3ee 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlin.concurrent.Volatile
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
@@ -34,8 +35,11 @@ import kotlin.time.Duration.Companion.minutes
data class InboundCoTMessage(val cotMessage: CoTMessage, val clientInfo: TAKClientInfo? = null)
interface TAKServerManager {
+ val isSupported: Boolean
val isRunning: StateFlow<Boolean>
+ val isStarting: StateFlow<Boolean>
val connectionCount: StateFlow<Int>
+ val hasStartError: StateFlow<Boolean>
val inboundMessages: SharedFlow<InboundCoTMessage>
/**
@@ -61,12 +65,22 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe
private var scope: CoroutineScope? = null
+ @Volatile private var startGeneration = 0L
+
+ override val isSupported = takServer.isSupported
+
private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
+ private val _isStarting = MutableStateFlow(false)
+ override val isStarting: StateFlow<Boolean> = _isStarting.asStateFlow()
+
// Mirror TAKServer's event-driven connection count β€” no polling needed
override val connectionCount: StateFlow<Int> = takServer.connectionCount
+ private val _hasStartError = MutableStateFlow(false)
+ override val hasStartError: StateFlow<Boolean> = _hasStartError.asStateFlow()
+
private val _inboundMessages = MutableSharedFlow<InboundCoTMessage>(extraBufferCapacity = 64)
override val inboundMessages: SharedFlow<InboundCoTMessage> = _inboundMessages.asSharedFlow()
@@ -80,6 +94,7 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe
private val offlineQueue = ArrayDeque<QueuedMessage>()
private val offlineQueueMutex = Mutex()
+ private val lifecycleMutex = Mutex()
companion object {
private val OFFLINE_QUEUE_TTL = 5.minutes
@@ -87,38 +102,51 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe
}
override fun start(scope: CoroutineScope) {
- if (_isRunning.value) {
+ if (!isSupported) return
+ if (_isRunning.value || _isStarting.value) {
Logger.w { "TAKServerManager already running" }
return
}
+ _hasStartError.value = false
+ _isStarting.value = true
+ val generation = ++startGeneration
// Assign scope AFTER the guard so a second concurrent start() can never
// overwrite the active scope without actually restarting the server.
this.scope = scope
scope.launch {
- // Wire up inbound message handler BEFORE starting so no messages are lost.
- // Use tryEmit (non-suspending) with extraBufferCapacity to avoid launching a
- // new coroutine per message, which would create unbounded coroutines under
- // high message rates and could reorder messages.
- takServer.onMessage = { cotMessage, clientInfo ->
- if (!_inboundMessages.tryEmit(InboundCoTMessage(cotMessage, clientInfo))) {
- Logger.w { "TAK inbound message buffer full; dropping message from ${clientInfo?.id}" }
+ lifecycleMutex.withLock {
+ if (generation != startGeneration) return@withLock
+ // Wire up inbound message handler BEFORE starting so no messages are lost.
+ // Use tryEmit (non-suspending) with extraBufferCapacity to avoid launching a
+ // new coroutine per message, which would create unbounded coroutines under
+ // high message rates and could reorder messages.
+ takServer.onMessage = { cotMessage, clientInfo ->
+ if (!_inboundMessages.tryEmit(InboundCoTMessage(cotMessage, clientInfo))) {
+ Logger.w { "TAK inbound message buffer full; dropping message from ${clientInfo?.id}" }
+ }
+ }
+ takServer.onClientConnected = {
+ drainOfflineQueue()
+ _clientConnected.tryEmit(Unit)
}
- }
- takServer.onClientConnected = {
- drainOfflineQueue()
- _clientConnected.tryEmit(Unit)
- }
- val result = takServer.start(scope)
- if (result.isSuccess) {
- _isRunning.value = true
- Logger.i { "TAK Server started" }
- } else {
- Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" }
- // Clear both callbacks if start failed so we don't hold a reference unnecessarily
- takServer.onMessage = null
- takServer.onClientConnected = null
+ val result = takServer.start(scope)
+ if (generation != startGeneration) {
+ if (result.isSuccess) takServer.stop()
+ return@withLock
+ }
+ _isStarting.value = false
+ if (result.isSuccess) {
+ _isRunning.value = true
+ Logger.i { "TAK Server started" }
+ } else {
+ _hasStartError.value = true
+ Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" }
+ // Clear both callbacks if start failed so we don't hold a reference unnecessarily
+ takServer.onMessage = null
+ takServer.onClientConnected = null
+ }
}
}
}
@@ -128,7 +156,10 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe
// any broadcast()/drainOfflineQueue() that races stop() sees _isRunning=false
// and exits early instead of launching coroutines on a scope that is about to
// be discarded.
+ startGeneration++
_isRunning.value = false
+ _isStarting.value = false
+ _hasStartError.value = false
scope = null
takServer.onMessage = null
takServer.onClientConnected = null

diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt
index f4a62f5ae3..f401521094 100644
--- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt
@@ -33,9 +33,17 @@ import kotlinx.coroutines.flow.asStateFlow
* only exercise the connected-client / broadcast surface (not the start/stop lifecycle) can ignore it.
*/
internal class FakeTAKServerManager : TAKServerManager {
+ override val isSupported = true
+
private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
+ private val _isStarting = MutableStateFlow(false)
+ override val isStarting: StateFlow<Boolean> = _isStarting.asStateFlow()
+
+ private val _hasStartError = MutableStateFlow(false)
+ override val hasStartError: StateFlow<Boolean> = _hasStartError.asStateFlow()
+
val connections = MutableStateFlow(0)
override val connectionCount: StateFlow<Int> = connections

diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKServerManagerTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKServerManagerTest.kt
index 95c2888570..4dd7d70fbc 100644
--- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKServerManagerTest.kt
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKServerManagerTest.kt
@@ -16,10 +16,12 @@
*/
package org.meshtastic.core.takserver
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -186,15 +188,61 @@ class TAKServerManagerTest {
override suspend fun hasConnections(): Boolean = false
}
+ private class DelayedTAKServer : TAKServer {
+ override val connectionCount: StateFlow<Int> = MutableStateFlow(0)
+ override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null
+ override var onClientConnected: (() -> Unit)? = null
+
+ val startResults = mutableListOf<CompletableDeferred<Result<Unit>>>()
+ var stopCount = 0
+
+ override suspend fun start(scope: CoroutineScope): Result<Unit> =
+ CompletableDeferred<Result<Unit>>().also(startResults::add).await()
+
+ override fun stop() {
+ stopCount++
+ }
+
+ override suspend fun broadcast(cotMessage: CoTMessage) {}
+
+ override suspend fun broadcastRawXml(xml: String) {}
+
+ override suspend fun hasConnections(): Boolean = false
+ }
+
@Test
- fun `start failure due to port conflict leaves isRunning false`() = runTest {
+ fun `start failure due to port conflict reports an error state`() = runTest {
val failingServer = FailingTAKServer()
val manager = TAKServerManagerImpl(failingServer)
manager.start(this)
advanceUntilIdle()
- // Manager should NOT be running after start failure
assertEquals(false, manager.isRunning.value)
+ assertTrue(manager.hasStartError.value)
+ }
+
+ @Test
+ fun `next start waits for stale start cleanup`() = runTest {
+ val delayedServer = DelayedTAKServer()
+ val manager = TAKServerManagerImpl(delayedServer)
+ manager.start(this)
+ runCurrent()
+ assertEquals(1, delayedServer.startResults.size)
+
+ manager.stop()
+ manager.start(this)
+ runCurrent()
+ assertEquals(1, delayedServer.startResults.size)
+
+ delayedServer.startResults[0].complete(Result.success(Unit))
+ runCurrent()
+ assertEquals(2, delayedServer.startResults.size)
+
+ delayedServer.startResults[1].complete(Result.success(Unit))
+ advanceUntilIdle()
+
+ assertTrue(manager.isRunning.value)
+ assertEquals(2, delayedServer.stopCount)
}
@Test

diff --git a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt
index 3aad75bd9a..4f20e24066 100644
--- a/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt
+++ b/core/takserver/src/iosMain/kotlin/org/meshtastic/core/takserver/TAKServerIos.kt
@@ -31,6 +31,7 @@ import org.meshtastic.core.di.CoroutineDispatchers
* `TAKServer` interface entirely.
*/
private class NoopTAKServer : TAKServer {
+ override val isSupported = false
private val _connectionCount = MutableStateFlow(0)
override val connectionCount: StateFlow<Int> = _connectionCount.asStateFlow()
override var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)? = null

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
index b8c98d97a8..ffcc450e7b 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
@@ -18,11 +18,15 @@
package org.meshtastic.feature.settings.radio.component
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -44,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.meshtastic.core.common.BuildConfigProvider
@@ -65,6 +70,14 @@ import org.meshtastic.core.resources.tak_server_loading
import org.meshtastic.core.resources.tak_server_mesh_to_cot
import org.meshtastic.core.resources.tak_server_mesh_to_cot_desc
import org.meshtastic.core.resources.tak_server_section
+import org.meshtastic.core.resources.tak_server_status
+import org.meshtastic.core.resources.tak_server_status_connected
+import org.meshtastic.core.resources.tak_server_status_failed
+import org.meshtastic.core.resources.tak_server_status_not_running
+import org.meshtastic.core.resources.tak_server_status_off
+import org.meshtastic.core.resources.tak_server_status_starting
+import org.meshtastic.core.resources.tak_server_status_unavailable
+import org.meshtastic.core.resources.tak_server_status_waiting
import org.meshtastic.core.resources.tak_server_test_card_title
import org.meshtastic.core.resources.tak_server_test_idle
import org.meshtastic.core.resources.tak_server_test_result_bytes
@@ -74,6 +87,7 @@ import org.meshtastic.core.resources.tak_server_test_run
import org.meshtastic.core.resources.tak_server_test_running
import org.meshtastic.core.resources.tak_team
import org.meshtastic.core.takserver.TAKDataPackageGenerator
+import org.meshtastic.core.takserver.TAKServerManager
import org.meshtastic.core.takserver.TakMeshTestRunner
import org.meshtastic.core.takserver.TakTestResult
import org.meshtastic.core.ui.component.DropDownPreference
@@ -180,8 +194,22 @@ internal fun handleTakPermissionResult(granted: Boolean, isTakServerEnabled: Boo
@Composable
fun TakServerScreen(onBack: () -> Unit) {
val takPrefs: TakPrefs = koinInject()
+ val takServerManager: TAKServerManager = koinInject()
val isTakServerEnabled by takPrefs.isTakServerEnabled.collectAsStateWithLifecycle()
val isMeshToCotEnabled by takPrefs.isMeshToCotEnabled.collectAsStateWithLifecycle()
+ val isTakServerRunning by takServerManager.isRunning.collectAsStateWithLifecycle()
+ val isTakServerStarting by takServerManager.isStarting.collectAsStateWithLifecycle()
+ val takClientCount by takServerManager.connectionCount.collectAsStateWithLifecycle()
+ val hasTakServerStartError by takServerManager.hasStartError.collectAsStateWithLifecycle()
+ val takServerStatus =
+ TakServerStatus.resolve(
+ isSupported = takServerManager.isSupported,
+ isEnabled = isTakServerEnabled,
+ isRunning = isTakServerRunning,
+ isStarting = isTakServerStarting,
+ clientCount = takClientCount,
+ hasStartError = hasTakServerStartError,
+ )
val exportLauncher = rememberDataPackageExporter { TAKDataPackageGenerator.generateDataPackage() }
TakPermissionHandler(
@@ -220,6 +248,8 @@ fun TakServerScreen(onBack: () -> Unit) {
onEnabledChange = { takPrefs.setTakServerEnabled(it) },
isMeshToCotEnabled = isMeshToCotEnabled,
onMeshToCotChange = { takPrefs.setMeshToCotEnabled(it) },
+ status = takServerStatus,
+ clientCount = takClientCount,
onExport = { exportLauncher("Meshtastic_TAK_Server.zip") },
)
TakMeshTestCard()
@@ -234,6 +264,8 @@ internal fun TakServerSection(
onEnabledChange: (Boolean) -> Unit,
isMeshToCotEnabled: Boolean,
onMeshToCotChange: (Boolean) -> Unit,
+ status: TakServerStatus,
+ clientCount: Int,
onExport: () -> Unit,
) {
TitledCard(title = stringResource(Res.string.tak_server_section)) {
@@ -244,6 +276,8 @@ internal fun TakServerSection(
enabled = true,
onCheckedChange = onEnabledChange,
)
+ HorizontalDivider()
+ TakServerStatusRow(status = status, clientCount = clientCount)
if (isTakServerEnabled) {
HorizontalDivider()
SwitchPreference(
@@ -281,6 +315,79 @@ internal fun TakServerSection(
}
}
+internal enum class TakServerStatus {
+ Unavailable,
+ Off,
+ Starting,
+ WaitingForClient,
+ Connected,
+ Failed,
+ NotRunning,
+ ;
+
+ companion object {
+ fun resolve(
+ isSupported: Boolean,
+ isEnabled: Boolean,
+ isRunning: Boolean,
+ isStarting: Boolean,
+ clientCount: Int,
+ hasStartError: Boolean,
+ ): TakServerStatus = when {
+ !isSupported -> Unavailable
+ !isEnabled -> Off
+ hasStartError -> Failed
+ isStarting -> Starting
+ isRunning && clientCount > 0 -> Connected
+ isRunning -> WaitingForClient
+ else -> NotRunning
+ }
+ }
+}
+
+@Composable
+private fun TakServerStatusRow(status: TakServerStatus, clientCount: Int) {
+ val (description, color) =
+ when (status) {
+ TakServerStatus.Unavailable ->
+ stringResource(Res.string.tak_server_status_unavailable) to MaterialTheme.colorScheme.onSurfaceVariant
+
+ TakServerStatus.Off ->
+ stringResource(Res.string.tak_server_status_off) to MaterialTheme.colorScheme.onSurfaceVariant
+
+ TakServerStatus.Starting ->
+ stringResource(Res.string.tak_server_status_starting) to MaterialTheme.colorScheme.onSurfaceVariant
+
+ TakServerStatus.WaitingForClient ->
+ stringResource(Res.string.tak_server_status_waiting) to MaterialTheme.colorScheme.primary
+
+ TakServerStatus.Connected ->
+ pluralStringResource(Res.plurals.tak_server_status_connected, clientCount, clientCount) to
+ MaterialTheme.colorScheme.primary
+
+ TakServerStatus.Failed ->
+ stringResource(Res.string.tak_server_status_failed) to MaterialTheme.colorScheme.error
+
+ TakServerStatus.NotRunning ->
+ stringResource(Res.string.tak_server_status_not_running) to MaterialTheme.colorScheme.onSurfaceVariant
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(modifier = Modifier.size(10.dp).background(color, CircleShape))
+ Column(modifier = Modifier.padding(start = 12.dp)) {
+ Text(text = stringResource(Res.string.tak_server_status), style = MaterialTheme.typography.bodyLarge)
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
// ── Debug-only TAK Mesh Test Card ────────────────────────────────────────────
@Composable

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt
index f969105dcf..e09c77746c 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt
@@ -48,6 +48,8 @@ fun TakServerSectionDisabledPreview() {
onEnabledChange = {},
isMeshToCotEnabled = false,
onMeshToCotChange = {},
+ status = TakServerStatus.Off,
+ clientCount = 0,
onExport = {},
)
}
@@ -62,6 +64,40 @@ fun TakServerSectionEnabledPreview() {
onEnabledChange = {},
isMeshToCotEnabled = true,
onMeshToCotChange = {},
+ status = TakServerStatus.WaitingForClient,
+ clientCount = 0,
+ onExport = {},
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+fun TakServerSectionConnectedPreview() {
+ AppTheme {
+ TakServerSection(
+ isTakServerEnabled = true,
+ onEnabledChange = {},
+ isMeshToCotEnabled = true,
+ onMeshToCotChange = {},
+ status = TakServerStatus.Connected,
+ clientCount = 1,
+ onExport = {},
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+fun TakServerSectionFailedPreview() {
+ AppTheme {
+ TakServerSection(
+ isTakServerEnabled = true,
+ onEnabledChange = {},
+ isMeshToCotEnabled = true,
+ onMeshToCotChange = {},
+ status = TakServerStatus.Failed,
+ clientCount = 0,
onExport = {},
)
}

diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TakServerStatusTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TakServerStatusTest.kt
new file mode 100644
index 0000000000..4af0eb17d1
--- /dev/null
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TakServerStatusTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.radio.component
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class TakServerStatusTest {
+
+ @Test
+ fun `disabled server is off`() {
+ assertEquals(TakServerStatus.Off, TakServerStatus.resolve(true, false, false, false, 0, false))
+ }
+
+ @Test
+ fun `enabled server reports startup failure`() {
+ assertEquals(TakServerStatus.Failed, TakServerStatus.resolve(true, true, false, false, 0, true))
+ }
+
+ @Test
+ fun `enabled server reports starting until it listens`() {
+ assertEquals(TakServerStatus.Starting, TakServerStatus.resolve(true, true, false, true, 0, false))
+ }
+
+ @Test
+ fun `listening server distinguishes waiting and connected clients`() {
+ assertEquals(TakServerStatus.WaitingForClient, TakServerStatus.resolve(true, true, true, false, 0, false))
+ assertEquals(TakServerStatus.Connected, TakServerStatus.resolve(true, true, true, false, 1, false))
+ }
+
+ @Test
+ fun `stopped server ignores stale client count`() {
+ assertEquals(TakServerStatus.NotRunning, TakServerStatus.resolve(true, true, false, false, 1, false))
+ }
+
+ @Test
+ fun `unsupported platform and inactive service report their distinct states`() {
+ assertEquals(TakServerStatus.Unavailable, TakServerStatus.resolve(false, true, false, false, 0, false))
+ assertEquals(TakServerStatus.NotRunning, TakServerStatus.resolve(true, true, false, false, 0, false))
+ }
+}

diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt
index efef7740fe..6c465a2e38 100644
--- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt
+++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt
@@ -42,8 +42,10 @@ import org.meshtastic.feature.settings.radio.component.PacketAuthenticityStrictC
import org.meshtastic.feature.settings.radio.component.PacketAuthenticityStrictPreview
import org.meshtastic.feature.settings.radio.component.PacketAuthenticityUnsupportedPreview
import org.meshtastic.feature.settings.radio.component.TakConfigCardPreview
+import org.meshtastic.feature.settings.radio.component.TakServerSectionConnectedPreview
import org.meshtastic.feature.settings.radio.component.TakServerSectionDisabledPreview
import org.meshtastic.feature.settings.radio.component.TakServerSectionEnabledPreview
+import org.meshtastic.feature.settings.radio.component.TakServerSectionFailedPreview
import org.meshtastic.feature.settings.radio.component.TakTestCardIdlePreview
import org.meshtastic.feature.settings.radio.component.TakTestCardResultsPreview
import org.meshtastic.feature.settings.radio.component.TakTestCardRunningPreview
@@ -97,6 +99,20 @@ fun ScreenshotTakServerSectionEnabled() {
TakServerSectionEnabledPreview()
}
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotTakServerSectionConnected() {
+ TakServerSectionConnectedPreview()
+}
+
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotTakServerSectionFailed() {
+ TakServerSectionFailedPreview()
+}
+
@PreviewTest
@PreviewLightDark
@Composable

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..56ad2b162a
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Dark_d19fbf1f_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..10d0855473
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionConnected_Light_b29dc7a7_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Dark_d19fbf1f_0.png
index f5310fc88d..25517f251d 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Dark_d19fbf1f_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png
index 34ea45c3ee..43dec6c39d 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionDisabled_Light_b29dc7a7_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png
index 4887426655..6d6472d433 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png
index e2e94408cc..c2d479d6f4 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..d5fb90ebf3
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Dark_d19fbf1f_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..3261ed4b13
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionFailed_Light_b29dc7a7_0.png differ

Served by rngit 1.4.2 - Generated in 0.32s